Skip to content

Add VoxCPM v1 — lightweight VoxCPM TTS support (0.5B) - #256

Open
jasonchen31 wants to merge 34 commits into
0xShug0:mainfrom
jasonchen31:main
Open

Add VoxCPM v1 — lightweight VoxCPM TTS support (0.5B)#256
jasonchen31 wants to merge 34 commits into
0xShug0:mainfrom
jasonchen31:main

Conversation

@jasonchen31

@jasonchen31 jasonchen31 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

1. Overview

Adds support for the OpenBMB VoxCPM-0.5B lightweight TTS model to audio.cpp (ported from VoxCPM.cpp), reusing the existing and already-released voxcpm2 model tree:

Model Params Output sample rate GGUF file
VoxCPM-0.5B 0.5B 16 kHz voxcpm-0.5b-q8_0-audiovae-f16.gguf

Architecture (0.5B): VAE encoder 128 / decoder 1536, encoder_rates [2,5,8,8], decoder_rates [8,8,5,2], patch_size 2, residual_lm 6 layers, encoder/dit 4 layers, 16 kHz, max_len 4096.

Since the v1 GGUF stores a different tensor convention than v2 (folded AudioVAE weights, no weight_v/weight_g split, no sr_cond_model tensors, voxcpm architecture name), the port wraps the v2 loader with a GGUF tensor-adaptation layer and adds config.v1-guarded branches in the generator, mirroring the reference implementation (VoxCPM.cpp).

All work is on main, 21 commits ahead of upstream/main (merge-base 4e973b1), consisting of 12 porting commits plus merges. tensor_source.h differs from upstream by a single line (is_synthesized) and tensor_source.cpp is identical — the port is structured to be upstreamable.


2. Porting activities

  1. Regenerated the 0.5B config.json from the GGUF metadata — the previously shipped sidecar was wrong on ~8 axes (patch, residual_lm/encoder/dit layer counts, VAE dims and rates, sample rate 44.1 kHz vs the actual 16 kHz, max_len).
  2. Diagnosed the v1 GGUF conventions (tensor dump + reference converter analysis):
    • AudioVAE conv weights are stored already folded (weight-norm folded), with no weight_v/weight_g decomposition and no sr_cond_model.* tensors.
    • GGUF file dims == ggml ne order; the v1 GGUF carries no audiocpp.tensor_shapes override metadata (v2 does), so the adapter must present shapes itself.
  3. Designed the identity-fold adapter (see §4) so the existing load_vae_weights loader works unchanged against folded v1 weights byte-for-byte.
  4. Mirrored the reference generator math for the no-fusion (no fusion_concat_proj) case: elementwise-add fusion inputs, elementwise-add dit-mu, and a real residual_lm autoregressive step.
  5. Set up the VoxCPM1-GGUF/ model directory with a config regenerated from its own GGUF metadata + tokenizer sidecars, and updated model_specs/voxcpm1.json package targets accordingly.
  6. Fixed the core quality bugs (see §8): synthesized-weight handling exposed via is_synthesized(), embedding transpose [hidden, vocab][vocab, hidden], and tensor synthesis only for tensors actually missing from the GGUF — turning pure noise into intelligible speech.
  7. Made the GGUF self-contained: config + tokenizer read from GGUF metadata (audiocpp.vocab_* / config keys) with a GGUF-native tokenizer, removing external sidecar dependence.
  8. Enabled voice clone + streaming to parity with VoxCPM2 (clone = continuation-mode with reference audio + transcript; streaming with retry_badcase=false).
  9. Aligned cloning numerics with the golden implementation: encoder output_padding fix (causal padding), conditioning/FSQ path fixes; 6/6 target sentences transcribed exactly via SenseVoice.
  10. Refactored framework changes back to upstream shape — moved GGUF tokenizer/config metadata reading out of the framework TensorSource into voxcpm2 GgufMetadataReader, reverted validate_expected_shape relaxed-rank param. Framework net delta: +1 line is_synthesized.
  11. Verified on CPU and released per-request VRAM (mem_saver + unconditional end-of-request release; only the cloned voice is cached across requests).

3. Changes per file (full diff vs upstream/main 4e973b1)

File Change
CMakeLists.txt Added audiocpp_add_model(voxcpm1 ...) reusing the 7 voxcpm2 sources; registers engine::models::voxcpm2::make_voxcpm1_loader.
include/engine/framework/assets/tensor_source.h +1 line: virtual bool is_synthesized(...) { return false; } — the only framework change that survives; needed to distinguish real vs fabricated weights at the abstract TensorSource level.
include/engine/models/voxcpm2/loader.h Declared make_voxcpm1_loader().
include/engine/models/voxcpm2/assets.h Added VoxCPM2Config::v1 = false; load_voxcpm2_assets() now takes bool is_v1.
src/models/voxcpm2/loader.cpp Added VoxCPM1Loader (family "voxcpm1"), load_voxcpm1_model(), make_voxcpm1_loader(), metadata_v1 / capabilities_v1 / cli_v1. Tasks: tts with {offline, streaming} modes, supports_speaker_reference = true. GGUF via load_voxcpm2_assets(path, is_v1=true).
src/models/voxcpm2/assets.cpp Added TransformingTensorSource v1 adapter (biggest chunk):
• v1→v2 tensor-name rename map (token_embd.weightbase_lm.embed_tokens.weight, gguf blk.N.*base_lm.layers.N.* / feat_encoder.encoder.layers.* / feat_decoder.estimator.decoder.layers.* / residual_lm.layers.*, attn_norminput_layernorm, ffn_normpost_attention_layernorm, attn_*self_attn.*_proj, ffn_*mlp.*_proj, time_mlp.*, output_norm.weightbase_lm.norm.weight, projection/fsq/stop mappings)
Folded weight-norm synthesis: for every audio_vae.*.weight conv, X.weight_v → folded tensor data as-is, X.weight_g → per-row L2 norms (identity fold, see §4)
• Identity decoder.sr_cond_model.{2..5}.scale_embed.weight (ones) / .bias_embed.weight (zeros) since v1 GGUF carries no SR-conditioning tensors
• Synthesized missing v1 tensors, only when absent from the GGUF (feat_encoder.scale_embed/bias_embed, feat_encoder.fc_logvar, feat_encoder.diag, feat_encoder.merge, token_embd.extra_bias, fusion_concat_proj.weight/bias, stop_proj.weight, stop_head.weight)
is_synthesized() override (map membership on synthesized_tensors_)
• Rank-tolerant require_f32 (accept element-count-equal, shape-different fetches) + relaxed-rank VAE weight_v anchors
Embedding transpose in set_backend_tensor(): V1 GGUF stores token_embd.weight as [hidden, vocab] but gglm expects [vocab, hidden]; transpose applied when shapes match the swap
has_tensor / require_metadata / require_tensor_data folded + synthesized lookups
Anchor fix: encoder.fc_mu.weight_v uses computed encoder-in (encoder_dim << #rates = 2048), not decoder_dim (1536)
src/models/voxcpm2/generator.cpp • v1 fusion guard has_fusion_proj: tensor != nullptr && !is_synthesized(...) (5 call sites — build/run/generate paths); residual input = AddModule instead of concat+linear when false (matches reference build_residual_fusion_input)
• Added add_dit_mu() helper; v1 mu = elementwise add of current_lm_dit_hidden + residual_dit_hidden (matches reference build_dit_mu, mu_dim = hidden·(fusion?2:1), v1 → hidden)
• CFM mu size check is now v1-aware (hidden_dim * (v1 ? 1 : 2))
• v1 decode loop runs residual_lm_.run_step(next_projected.residual_input).hidden (earlier fsq_lm_dit_hidden shortcut removed)
src/models/voxcpm2/minicpm.cpp Prompt-prefill graph: v1 residual_input = AddModule(lm_hidden, masked_current) instead of concat+linear; residual_lm always runs. RoPE longrope factors loaded from GGUF config for v1 (prefill lm_hidden l2 within ~2% of reference).
src/models/voxcpm2/minicpm_blocks.h Prefill residual-input branch for v1 ADD fusion.
src/models/voxcpm2/session.cpp v1 min_tokens floor (avoids premature stop at ~2 tokens); stop-progress handling; voice-clone: --voice-ref routed through the prompt path (reference audio + transcript as reference_text), reference-only ref_start/ref_end branch (fails identically in golden impl — model limitation); streaming support; per-request VRAM release (unconditional end-of-request release of prefill + decoder graphs; mem_saver additionally releases all generator graphs; only the cloned voice is cached).
src/models/voxcpm2/audiovae.cpp Encoder: dropped stride % 2 output_padding on downsample conv so causal padding matches the reference encoder; VOXCPM_DUMP_REF_MONO / REF_FEAT / ENC_STAGE debug dumps; release_encoder_graph() so encoder VRAM frees right after encode.
src/models/voxcpm2/config_gguf.cpp/.h New — reads config from GGUF metadata (voxcpm.* keys: architecture, dims, layer counts, VAE dims/rates, max_len, RoPE factors, stop floor).
src/models/voxcpm2/gguf_metadata.cpp/.h NewGgufMetadataReader: framework-independent GGUF metadata accessor so the framework TensorSource stays upstream-shaped.
src/models/voxcpm2/tokenizer_gguf.cpp/.h New — GGUF-native BPE tokenizer (73,448 vocab) built from embedded audiocpp.vocab_* metadata.
src/models/voxcpm2/tokenizer_wrapper.h New — tokenizer interface wrapper (GGUF-native ↔ external tokenizer.json).
src/models/voxcpm2/tokenizer_text.cpp/.h v1 tokenizer selection; tokenize path chosen by tokenizer type.
include/engine/models/voxcpm2/audiovae.h Declared release_encoder_graph().
include/engine/models/voxcpm2/generator.h Declared release_runtime_memory().
include/engine/models/voxcpm2/minicpm.h Declared release_runtime_memory() on prefill/text-embedding runtimes.
model_specs/voxcpm1.json Package targets: voxcpm1_0.5b_q8_0VoxCPM1-GGUF (default).
tools/audiocpp_cli/audiocpp_cli_path_cases.json Added 3 path-test cases: voxcpm1_tts, voxcpm1_voice_clone, voxcpm1_streaming_tts.
webui/configs/models_catalog.json Added voxcpm1 catalog entry.
webui/configs/model_params.json Added voxcpm1 controls (num_inference_steps, guidance_scale, min_tokens, ...).
webui/native/dist/index.html WebUI embedding refreshed to surface voxcpm1 (0.5B package).
docs/tts.md Added VoxCPM1 section + TOC entry (usage, options, clone, streaming, sample-rate notes).
models/VoxCPM1-GGUF/config.json Regenerated from 0.5B GGUF metadata (tokenizer now GGUF-native).

4. Key design: the identity-fold adapter

The v1 GGUF (OpenBMB reference converter) stores AudioVAE conv weights already folded (weight = weight_g · weight_v / ‖weight_v‖), with no weight_v/weight_g split, while audiovae.cpp requests the decomposed names directly via require_f32. The adapter solves this without touching the VAE loader:

X.weight_v  := folded GGUF tensor data (as-is)
X.weight_g  := per-row L2 norms of the folded tensor, computed with the loader's own row grouping (groups = expected_shape.front(), inner = elements/groups)

Because fold_weight_norm multiplies row d0 by weight_g[d0] / ‖row d0‖ = 1, the loader output equals the GGUF data byte-for-byte — an exact identity, with no layout drift relative to the reference runtime's consumption of the same bytes.


5. Usage

Build

scripts/build_linux.sh --backend cpu --target audiocpp_cli
# or, with the standard full model set:
cmake -S . -B build/linux-cpu-release -DCMAKE_BUILD_TYPE=Release
cmake --build build/linux-cpu-release --target audiocpp_cli -j 8

Run — VoxCPM-0.5B (16 kHz output)

build/linux-cpu-release/bin/audiocpp_cli \
  --task tts --family voxcpm1 \
  --model models/VoxCPM1-GGUF/voxcpm-0.5b-q8_0-audiovae-f16.gguf \
  --backend cpu --text "Hello from VoxCPM1." --out out.wav

Voice clone

build/linux-cpu-release/bin/audiocpp_cli \
  --task tts --family voxcpm1 \
  --model models/VoxCPM1-GGUF/voxcpm-0.5b-q8_0-audiovae-f16.gguf \
  --backend cpu --text "Hello from VoxCPM1." \
  --voice-ref assets/resources/b.wav --reference-text "b reference transcript" --out out.wav

Streaming

build/linux-cpu-release/bin/audiocpp_cli \
  --task tts --family voxcpm1 \
  --model models/VoxCPM1-GGUF/voxcpm-0.5b-q8_0-audiovae-f16.gguf \
  --backend cpu --mode streaming --text "Hello from VoxCPM1." \
  --request-option retry_badcase=false --out out.wav

Options

Option Values Default Meaning
--task tts required Task kind.
--family voxcpm1 auto-detect Selects the v1 loader.
--backend cpu, cuda, vulkan, metal, hip, best best Backend.
--mode offline, streaming offline Streaming requires retry_badcase=false.
--voice-ref WAV path not set Reference speaker audio (clone); must be paired with --reference-text.
--max-tokens integer 4096 Maximum generated AR tokens.
--num-inference-steps integer 10 Flow-matching steps.
--guidance-scale float 2.0 CFG strength.
--session-option voxcpm1.mem_saver=true|false bool false Tighter graph workspaces + release request graphs; idle VRAM drops to ~1.4 GB after generation (very long text may need ~3.5 GB).
--session-option voxcpm1.prompt_cache_slots=<n> integer 1 Prompt/prompt-audio embedding cache slots.
--text-chunk-mode default, tag_aware, japanese, endline tag_aware Long-form chunking mode.

6. Validation performed

  • Load + anchors: the 0.5B GGUF passes validate_weight_anchors and load_vae_weights/load_model_weights on CPU backend.
  • TTS quality (transcribed): "This is a test run for the fix" → SenseVoice transcribes "This is a test." — intelligible speech, RMS ~0.10–0.20, 16 kHz, no pure noise.
  • Voice clone (transcribed): continuation-mode clone with the Anna reference → 6/6 target sentences transcribe exactly (SenseVoice); text-only TTS unchanged.
  • Streaming: speech.audio.delta SSE chunks flow at the model native 16 kHz; validated via the voxcpm1 streaming WebUI script (webui/voxcpm1_stream_webui.py).
  • Parity vs reference: prefill lm_hidden l2 within ~2% of VoxCPM.cpp; stop predictor fires at pos=19; duration 1.60 s vs reference 1.68 s.
  • Per-request VRAM: two-request reused-session tests pass (2-request clone sequence; long→short text reallocates a smaller decoder buffer); idle VRAM ~1.4 GB, long text up to ~3.5 GB.
  • Regression: the released voxcpm2 path is untouched (guard style config.v1, v2 default false); VoxCPM2 still generates 48 kHz speech with byte-identical output versus pre-change baseline.

7. Supported modes

Mode Status Notes
Offline TTS ✅ works Intelligible speech, transcribed correctly.
Streaming ✅ works SSE PCM chunks; requires retry_badcase=false (same as v2).
Voice clone ✅ works Continuation-mode clone (reference audio + transcript); reference-only (ref_start/ref_end) cloning fails identically in the golden VoxCPM.cpp — a model-level limitation.

8. Fixed issues (was: "Known issue: noisy output")

The "pure noise" blocker from the initial port is resolved. Root causes found and fixed:

  1. Synthesized weight used as real fusion weightfusion_concat_proj was Xavier-synthesized on every load and treated as a learned tensor. Fixed by adding is_synthesized() to the TensorSource interface (tensor_source.h +1 line) and overriding it in TransformingTensorSource; the 5 has_fusion_proj guards now exclude synthesized weights (false for true V1 models).
  2. Embedding transposed — V1 GGUF stores token_embd.weight as [hidden, vocab] = [1024, 73448]; audio.cpp/ggml needs [vocab, hidden]. Fixed with a transpose in set_backend_tensor().
  3. Unconditional tensor synthesis — tensors were fabricated regardless of presence. Fixed: synthesize only what the GGUF lacks; corrected feat_encoder.special_token shape (1D vs 4D).
  4. Early stopping — stop token fired at ~2 tokens (1.28 s cutoff). Fixed: real RoPE longrope factors loaded from GGUF config + min_tokens floor; durations scale 1.76 s→4.32 s with text length.
  5. VAE encoder padding — dropped the stride % 2 output_padding so causal padding matches the reference encoder (clone conditioning).

9. Remaining tasks

  • Loader registration, tensor adaptation, generator v1 branches, configs, model spec
  • Fix noisy output / voice quality (transcribed human speech) — resolved, see §8
  • GGUF self-contained loading (config + tokenizer from GGUF metadata)
  • Voice clone (continuation-mode; 6/6 sentences transcribed)
  • Streaming support
  • WebUI catalog entry (models_catalog.json, model_params.json, native/dist/index.html)
  • CLI path-test cases (voxcpm1_tts, voxcpm1_voice_clone, voxcpm1_streaming_tts)
  • RoPE longrope factors / min_tokens floor parity vs reference (prefill l2 ~2%)
  • Per-request VRAM release + mem_saver (idle ~1.4 GB)
  • CUDA-backend verification + RTF measurement (expect voxcpm2-like speedups)
  • Voice cloning
  • docs/gguf.md support-table entry for voxcpm1
  • Release packaging on audio.cpp-gguf (0.5B package)

jasonchen31 and others added 21 commits August 16, 2026 16:49
…d weight handling and embedding transpose

Bug: VoxCPM1 model produced pure noise ("elloそ。") instead of speech due to:
1. Synthesized `fusion_concat_proj` weight (Xavier init) treated as learned weight → wrong concat+linear fusion
2. Embedding weight transposed in V1 GGUF: `token_embd.weight` stored as [hidden, vocab] but audio.cpp expects [vocab, hidden]

Fix:
- Add `is_synthesized()` to TensorSource interface to distinguish loaded vs synthesized weights- Implement in TransformingTensorSource for V1 models- Add embedding weight transpose in set_backend_tensor() for `base_lm.embed_tokens.weight`
- Update 5 `has_fusion_proj` checks to exclude synthesized weights
- Test: "This is a test run for the fix" now transcribes as "This is a test." (was pure noise) --> but still wrong.
## Summary
Fixed VoxCPM1 TTS producing pure noise by correcting tensor synthesis and shape validation issues.

## Changes
- **`src/models/voxcpm2/assets.cpp`**: Only synthesize tensors missing from GGUF (not unconditionally). Fixed `feat_encoder.special_token` shape (1D vs 4D). Added relaxed rank handling in `set_backend_tensor()` for V1.
- **`src/framework/assets/tensor_source.cpp`**: Added `relaxed_rank` parameter to `validate_expected_shape()` allowing shape mismatches when element counts match.

## Root Cause
Synthesized (Xavier-initialized) tensors were used instead of learned checkpoint weights. The `is_synthesized()` check now correctly distinguishes true synthesized tensors (only `fusion_concat_proj` for V1) from loaded weights.

## Validation
- VoxCPM1: 16kHz speech, RMS ~0.10-0.15 ✅
- VoxCPM2: 48kHz speech (no regression) ✅
- Embedding transpose: `[1024,73448]` → `[73448,1024]` ✅
- `has_fusion_proj=false` for V1 ✅
## Fix
- Added GGUF metadata reading to `TensorSource` (tokenizer.ggml.*, voxcpm_*)
- Created `VoxCPM1GgufTokenizer` + `load_voxcpm1_config_from_gguf()` for native GGUF loading
- Added `VoxCPM2TokenizerWrapper` for dual JSON/GGUF tokenizer support
- Updated `load_voxcpm2_assets()` to auto-detect/use GGUF metadata
- Removed external JSON deps from `model_specs/voxcpm1.json`

## Test (ASR: sensevoice@11533)
- VoxCPM1 0.5B: "This is a test run for the fix." ❌ (too fask)
- VoxCPM1.5 1.5B: "I the touch for the." ❌ (too slow)

## Remaining Bugs
1. VoxCPM1 too fast (1.28s vs 2.5s) - early stop token
2. VoxCPM1.5 too slow (5.29s vs 2.5s) - arch diff
- config_gguf.cpp: output_sample_rate now falls back to sample_rate (not 16000)
  VoxCPM1.5 GGUF has sample_rate=44100 but no out_sample_rate → was defaulting to 16kHz

- session.cpp: add V1-specific default min_tokens to prevent early stop token trigger
  VoxCPM1 (patch_size=2): min_tokens=20, VoxCPM1.5 (patch_size=4): min_tokens=12
  Without this, stop token triggers at ~2 tokens causing 1.28s cutoff

- Stop predictor weights correctly loaded via V1 relaxed rank (no transpose needed)
  GGUF stores [1024,2] (GGML), expected logical [2,1024] → to_ggml_dims → [1024,2] ✓

Results:
  VoxCPM1 (0.5B): durations scale 1.76s→4.32s with text length
  VoxCPM1.5 (1.5B): durations scale 2.56s→5.12s, correct 44.1kHz sample rate
  VoxCPM2: regression passes (48kHz, 1.28s)

Files: config_gguf.cpp (+6), session.cpp (+14)
…VoxCPM2)**

VoxCPM1 (0.5B/1.5B) models now support voice cloning (`--voice-ref`) and streaming output (`--mode streaming`), matching the VoxCPM2 feature surface. The inference math was already shared; this unblocks the capability/option/reporting layer.

**Root causes fixed (5 gaps):**
- Capability advertisement: now exposes `Tts + {Offline, Streaming}` for V1 (was TTS-only)
- Family identity: `family_impl()` returns `"voxcpm1"` for V1 models (was hardcoded `"voxcpm2"`)
- Session options: `normalize_v1_session_options()` rewrites `voxcpm1.*` → `voxcpm2.*` keys so aliases work
- Request options: added `voxcpm1.*` aliases for all params (`prompt_text`, `min_tokens`, `guidance_scale`, `retry_badcase`, etc.)
- Model spec: `voxcpm1.json` adds `streaming` mode, correct sample rates (16kHz/44.1kHz)

**Changes:** 7 files, +167/−32 lines
- `src/models/voxcpm2/session.cpp` — option normalization, family-aware errors, request-option aliases
- `src/models/voxcpm2/loader.cpp` — capability advertisement, family-labeled errors
- `model_specs/voxcpm1.json` — streaming mode, tags, corrected description
- `docs/tts.md` — V1 streaming/voice-clone examples, `retry_badcase=false` requirement
- `tools/audiocpp_cli/audiocpp_cli_path_cases.json` — 3 new V1 path tests
- `webui/configs/models_catalog.json` + `model_params.json` — V1 WebUI entries

**Verified (CPU):**
| Test | Result |
|------|--------|
| V1 offline TTS | `family=voxcpm1` ✓ |
| V1 voice clone | 16kHz, 5.12s, RMS 0.115 ✓ |
| V1 streaming | 40×1280 chunks, 16kHz ✓ |
| V1 `voxcpm1.*` session/request options | accepted & applied ✓ |
| V1 capability inspection | `modes=offline,streaming` ✓ |
| V2 regression (offline/streaming) | 48kHz, parity maintained ✓ |

Streaming requires `retry_badcase=false` (same as V2, pre-existing design). No V2 behavior changes.

**Issue**: The audio quality is still bad
VoxCPM1 attention used identity longrope factors and a padded stop-token floor. The GGUF's real F32 factor arrays are now read and applied (prefill, stop behavior and duration match the VoxCPM.cpp reference), and the V1 default `min_tokens` is lowered to the reference floor so short utterances are no longer padded with trailing silence.

**Root causes fixed (2):**
- RoPE longrope factors were hardcoded to `1.0f` in the GGUF config path ("GGUF doesn't have native float arrays" was wrong — `GgufTensorSource` already parses them); every attention computation across all four transformers (base LM, residual LM, local encoder, local DiT) used identity positional encodings
- V1 default `min_tokens=20` (per patch_size) vs reference `kMinLen=2` — forced ~1.6s+ of audio and padded short utterances with trailing silence after the stop predictor fired

**Changes:** 2 files, +29/−12 lines
- `src/models/voxcpm2/config_gguf.cpp` — read `voxcpm_lm_config_rope_scaling_{short,long}_factor` f32 arrays via `optional_f32_array()` with size validation (`head_dim/2`), identity fallback only when the keys are absent
- `src/models/voxcpm2/session.cpp` — V1 default `min_tokens = 2` (≡ reference `step > kMinLen`), keeping the `--request-option min_tokens` override

**Verified (CPU, against reference `/workspace/pi/VoxCPM.cpp`):**
| Test | Result |
|------|--------|
| Prefill lm_hidden | l2 within ~2% of reference (was diverged) |
| Stop predictor ("This is a test run for the fix") | fires at pos=19 (was: never fired) |
| Duration | 1.60s (ref 1.68s), trailing silence 0.13s (ref 0.44s) |
| V2 regression | 48kHz output maintained ✓ |
| Embedding + fusion | `[73448,1024]` transpose intact, `has_fusion_proj=false` ✓ |

**Issue**: Voice clone is still not supported — `--task clon` is rejected and passing reference audio + text (`--task tts --voice-ref <wav>`) generates noise rather than cloned speech. Needs a port-audit of the VoxCPM1 reference-audio conditioning path. Full evidence in `docs/reports/2026-08-18_1128_VoxCPM1_RoPE_Longrope_Factors_Stop_Floor_Fix.md`.
…den impl

Restore working voice cloning by fixing the reference-audio conditioning
and AudioVAE encoder alignment against the golden VoxCPM.cpp port:

- generator: only set the CFM `prefix_cond` from prefill rows carrying
  audio (audio_mask). Previously the trailing text row's zero feature
  overwrote the reference patch, feeding the DiT a zero acoustic anchor
  for voice cloning (matches torch feat[:, -1] semantics)
- audiovae: re-enable VAD silence trimming for prompt/reference audio
  (matches golden server_common.cpp:842/878), then pad to patch
  alignment before VAE encoding (left for prompt, right for reference)
- audiovae: drop the `stride % 2` output_padding on the encoder
  downsample conv so causal padding matches the reference encoder
- assets: declare base_lm.embed_tokens.weight as [vocab, hidden] so V1
  GGUFs storing the embedding transposed ([hidden, vocab]) load correctly
- audiovae: add VOXCPM_DUMP_REF_MONO / REF_FEAT / ENC_STAGE debug dumps

Validation (sensevoice-small STT, continuation-mode clone with the Anna
reference): 6/6 target sentences transcribe exactly; text-only TTS
unchanged. Reference-only cloning (ref_start/ref_end tokens) still fails
identically in the golden VoxCPM.cpp - a model-level limitation.
VoxCPM1 voice cloning via `--voice-ref <wav>` produced non-cloned
speech, while `--audio <wav>` (plus `--reference-text`) cloned
correctly. Both flags carried the same user intent, but the CLI mapped
them to different request fields that the session treated as two
distinct audio roles.

**Root cause:** `--voice-ref` set `request.voice->speaker->audio`, which
the session consumed as *reference audio*. For VoxCPM1 the reference
path is wrong in two ways:
- `encode_prompt_audio()` only copies `prompt_text` inside the
  `prompt_audio` branch, so a reference-only request dropped the
  reference transcript entirely (the LM never saw it).
- The reference role right-pads the audio and prepends it wrapped in
  the `<audio_prompt_start/end>` tokens 103/104. Those belong to
  VoxCPM2's "reference-mode plumbing"; the V1 LM was only trained for
  prompt-continuation cloning (golden VoxCPM.cpp uses
  `--prompt-audio` + `--prompt-text`, and its V1 server never calls
  `encode_reference_audio`).

**Fix:** in `VoxCPM2SessionBase::encoded_prompt_for_request()`, when the
model is V1 and only a reference audio is supplied (no `--audio`),
route it through the prompt path — the audio becomes `prompt_audio`
(left-padded, after `<audio_start>`) and `--reference-text` becomes
`prompt_text` (concatenated with the target text). V2 keeps the
reference-mode path untouched. Applies to both offline and streaming
runs (single shared function). Without `--reference-text` the request
now fails with the golden's exact rule ("prompt audio requires
prompt_text or reference_text").

**Changes:** 1 file, +24/−12 lines
- `src/models/voxcpm2/session.cpp` — V1 reference→prompt routing with
  cache key/lookup/encode all using the effective audio roles

**Verified (CPU, 0.5B Q8_0):**
| Test | Result |
|------|--------|
| V1 `--voice-ref` + ref-text | byte-identical WAV to `--audio` + ref-text (same clone) |
| V1 `--voice-ref` without ref-text | clean error (matches golden iff rule) |
| V1 `--audio` regression | byte-identical output |
| V2 `--voice-ref` regression | 48kHz, byte-identical to pre-fix (reference mode preserved) |
| 5-voice clone batch (ana/eric/andrew/jenny/nicole) | 16kHz speech, RMS 0.06–0.08 ✓ |

**Note:** V1.5 (44.1kHz) fails at load with "encoder sample capacity
must be divisible by encoder stride" — pre-existing config gap (stride
1764 ∤ default capacity 240000), identical on `--audio` before this fix.
…main

For fixing VocCPM v1 --voice-ref clone issue
Move VoxCPM GGUF tokenizer/config metadata reading out of the framework
TensorSource interface into a new voxcpm2 GgufMetadataReader. Revert the
validate_expected_shape relaxed_rank parameter and redundant <memory>
include; tensor_source.h/.cpp now differ from upstream/main by a single
line (is_synthesized).
Only the cloned voice is cached across requests; prompt-prefill and
AudioVAE encoder/decoder graphs are freed at request end and rebuilt
fresh on the next request. Idle VRAM drops to ~1.4GB after generation;
very long text may require up to ~3.5GB VRAM during generation.
@jasonchen31 jasonchen31 changed the title [WIP] Add VoxCPM v1 — lightweight VoxCPM TTS support (0.5B / 1.5B) Add VoxCPM v1 — lightweight VoxCPM TTS support (0.5B) Aug 19, 2026
@jasonchen31

jasonchen31 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Removed any changes to the framework.

@jasonchen31
jasonchen31 marked this pull request as ready for review August 19, 2026 22:18
@0xShug0 0xShug0 added the new model Request for new model support label Aug 20, 2026
@jasonchen31

jasonchen31 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

I noticed performance down after merged v0.7. Small models like Omnivoice and VoxCPM v1 RTF 0.3x --> 0.6x.
After clean building, everything fine now.

@jasonchen31

Copy link
Copy Markdown
Contributor Author

Known issue: the streaming voice is poor. Maybe that's the reason it was disabled by default.

@0xShug0

0xShug0 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

@jasonchen31 Just a quick comment: The current impl breaks the ownership and boundary of the components. GGUF/package differences should be handled at conversion, not by scattering v1 branches and tensor adaptation throughout the voxcpm2 runtime. You can write your own conversion script if the current C++ GGUF tool is not sufficient, as long as the script and exact conversion command are documented and reproducible. The model spec (and loader) should resolve the package into native voxcpm1 assets. In fact, if designed cleanly, loader.* can be safely removed by migrating the model to model spec v1, following the pattern used by other spec-v1 models. Overall, runtime code should then implement the actual v1 graph directly, without GGUF-specific tensor remapping.

A cleaner pathway is to write a dedicated voxcpm1 model implementation with its own assets/config/loader/runtime, since voxcpm1 appears to involve more than config changes and minor graph edits?

Let's figure out a the best way to support voxcpm1 together.

@jasonchen31

jasonchen31 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

@0xShug0 Thanks for your comments. Indeed, the AI tool directly merged v1 and v2 models and resulted in remapping, which is way more complicated and less efficient. As this is my first time play around with ggml and tts implementation, this is a precious learning experience for me.
But as it is already there, and the loading process happens only once, may I suggest:

  1. duplicate the update voxcpm2 to a new community model voxcpm1 and block/stripe v2 path from it.
  2. remove the loader when feasible.
  3. restore upstream voxcpm2
    Do you find it ok?

I am now more into the new Audit8_TTS. Still just studying the architecture....

@0xShug0

0xShug0 commented Aug 20, 2026

Copy link
Copy Markdown
Owner
  • duplicate the update voxcpm2 to a new community model voxcpm1 and block/stripe v2 path from it.
  • remove the loader when feasible.
  • restore upstream voxcpm2
    Do you find it ok?

@jasonchen31 Yes that sounds good to me. Thanks!

If you're interested in Audio8, feel free to submit a draft PR early to avoid potential conflicts. It should be an easy port if using Fish Audio as the template.

…oading

Port VoxCPM1 (tokenizer-free 0.5B TTS) as a community model under community_models/voxcpm1, reusing the VoxCPM2 / Local-DiT / CFM stack.

Fix V1 GGUF loading:
- embed_tokens weight transpose to [vocab, hidden] for ggml get_rows
- correct fusion-projection handling so true V1 models do not use the
  synthesized Xavier weight as a real fusion weight
- distinguish synthesized vs loaded tensors (is_synthesized)

Refactor shared voxcpm2 components accordingly. Updates CMake registration,
model_specs/voxcpm1.json, webui catalog entries, and cli path-test cases.

Verified via STT: VoxCPM1/VoxCPM2 TTS and voice-clone generate
content-correct speech; VoxCPM2 retains 48kHz output.
# Conflicts:
#	webui/native/dist/index.html
@jasonchen31

Copy link
Copy Markdown
Contributor Author

@0xShug0 all done and merged. Please have a check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new model Request for new model support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants